Exercise: QR Codes
Consider the following program that uses a device's camera to scan a QR code and then navigates to the URL represented by the QR code on the device's browser:
class Camera(StrEnum):
FRONT = "front"
BACK = "back"
@dataclass
class QRScanner:
camera: Camera = Camera.FRONT
def choose_camera(self, camera: Camera) -> None:
print(f"Choosing camera {camera.value}.")
self.camera = camera
def scan(self) -> str:
print(f"Scanning QR code with {self.camera.value} camera.")
return "https://www.arjancodes.com"
class Browser:
def open(self, url: str) -> None:
print(f"Opening {url} in the browser.")
def open_from_qr_code(self) -> None:
qr = QRScanner()
qr.choose_camera(Camera.BACK)
url = qr.scan()
self.open(url)
def main() -> None:
print("Navigating to website on device.")
browser = Browser()
browser.open_from_qr_code()
FRONT = "front"
BACK = "back"
@dataclass
class QRScanner:
camera: Camera = Camera.FRONT
def choose_camera(self, camera: Camera) -> None:
print(f"Choosing camera {camera.value}.")
self.camera = camera
def scan(self) -> str:
print(f"Scanning QR code with {self.camera.value} camera.")
return "https://www.arjancodes.com"
class Browser:
def open(self, url: str) -> None:
print(f"Opening {url} in the browser.")
def open_from_qr_code(self) -> None:
qr = QRScanner()
qr.choose_camera(Camera.BACK)
url = qr.scan()
self.open(url)
def main() -> None:
print("Navigating to website on device.")
browser = Browser()
browser.open_from_qr_code()
Apply the "Separate Creation From Use" principle to refactor this code.
Compatible Python Versions: 3.11+
Going functional for the scan seems simpler here:
from enum import StrEnum, auto
class Camera(StrEnum):
FRONT = auto()
BACK = auto()
def scan(camera: Camera) -> str:
print(f"Scanning QR code with {camera.value} camera.")
return "https://www.arjancodes.com"
class Browser:
def open(self, url: str) -> None:
print(f"Opening {url} in the browser.")
def open_from_qr_code(self) -> None:
url = scan(Camera.BACK)
self.open(url)
def main() -> None:
print("Navigating to website on device.")
browser = Browser()
browser.open_from_qr_code()
if __name__ == "__main__":
main()
Hay. My solution:
1 qr:
from dataclasses import dataclass
from enum import Enum
class Camera(str, Enum):
FRONT = "front"
BACK = "back"
@dataclass
class QRScanner:
camera: Camera = Camera.FRONT
def choose_camera(self, camera: Camera) -> None:
print(f"Choosing camera {camera.value}.")
self.camera = camera
def scan(self) -> str:
print(f"Scanning QR code with {self.camera.value} camera.")
return "https://www.arjancodes.com"
class Browser:
def open(self, url: str) -> None:
print(f"Opening {url} in the browser.")
def open_from_qr_code(self,qr) -> None:
url = qr.scan()
self.open(url)
def get_qr_reader() -> QRScanner:
qr = QRScanner()
qr.choose_camera(Camera.BACK)
return qr
def main() -> None:
print("Navigating to website on device.")
# creation
qr = get_qr_reader()
browser = Browser()
# use
browser.open_from_qr_code(qr)
if __name__ == "__main__":
main()
ex2: game
import random
from dataclasses import dataclass, field
from enum import StrEnum
from typing import Callable, Protocol
class EnemyType(StrEnum):
KNIGHT = "knight"
ARCHER = "archer"
WIZARD = "wizard"
@dataclass
class Enemy:
enemy_type: EnemyType
health: int
attack_power: int
defense: int
SkillsFunction = Callable[[], tuple[int,int,int]]
def easy_spawn_points(seed: int =0) -> tuple[int,int,int]:
random.seed(seed)
health = random.randint(0, 20)
attack_power = random.randint(0, 20)
defense = random.randint(0, 20)
return health, attack_power, defense
def medium_spawn_points(seed: int = 0) -> tuple[int,int,int]:
random.seed(seed)
health = random.randint(21, 60)
attack_power = random.randint(21, 60)
defense = random.randint(21, 60)
return health, attack_power, defense
def hard_spawn_points(seed: int = 0) -> tuple[int,int,int]:
random.seed(seed)
health = random.randint(61,100)
attack_power = random.randint(61,100)
defense = random.randint(61,100)
return health, attack_power, defense
class EnemyFactory(Protocol):
def set_enemy_type(self) -> None:
...
def spawn(self) -> None:
...
@dataclass
class EasyEnemyFactory:
enemy_type: EnemyType = EnemyType.KNIGHT
enemy_type_options: list = field(default_factory=lambda: [EnemyType.KNIGHT, EnemyType.ARCHER])
def set_enemy_type(self) -> None:
choice = input("Enter the enemy type: knight or archer: ")
while choice not in self.enemy_type_options:
print("Choose a valid Enemy Type")
choice = input("Enter the enemy type: knight")
self.enemy_type = choice
def spawn(self, skills: SkillsFunction) -> Enemy:
health, attack_power, defense = skills()
return Enemy(self.enemy_type, health, attack_power, defense)
@dataclass
class MediumEnemyFactory:
enemy_type: EnemyType = EnemyType.KNIGHT
enemy_type_options: list = field(default_factory=lambda: [EnemyType.KNIGHT, EnemyType.ARCHER, EnemyType.WIZARD])
def set_enemy_type(self) -> None:
choice = input("Enter the enemy type: knight or archer or wizard: ")
while choice not in self.enemy_type_options:
print("Choose a valid Enemy Type")
choice = input("Enter the enemy type: knight or archer or wizard: ")
self.enemy_type = choice
def spawn(self, skills: SkillsFunction) -> Enemy:
health, attack_power, defense = skills()
return Enemy(self.enemy_type, health, attack_power, defense)
@dataclass
class HardEnemyFactory:
enemy_type: EnemyType = EnemyType.WIZARD
def set_enemy_type(self) -> None:
self.enemy_type = EnemyType.WIZARD
def spawn(self, skills: SkillsFunction) -> Enemy:
health, attack_power, defense = skills()
return Enemy(self.enemy_type, health, attack_power, defense)
SKILLS: dict[int,SkillsFunction] = {
"easy": easy_spawn_points,
"medium": medium_spawn_points,
"hard": hard_spawn_points
}
FACTORY: dict[str,EnemyFactory] = {
"easy": EasyEnemyFactory,
"medium": MediumEnemyFactory,
"hard": HardEnemyFactory
}
def create_factory() -> tuple [EnemyFactory, SkillsFunction]:
while True:
choice = input(f"Enter the difficulty level ({', '.join(FACTORY)}): ")
try:
factory = FACTORY[choice]()
factory.set_enemy_type()
skills = SKILLS[choice]
return factory, skills
except KeyError:
print(f"Unknown difficulty level: {choice}.")
def main() -> None:
enemyfactory, skills = create_factory()
enemy = enemyfactory.spawn(skills)
print(enemy)
if __name__ == "__main__":
main()
Looks good! However, some improvements can be made!
* First and foremost, what Python version are you using? In ex1, you are using
Camera(str, Enum):, but for ex2class EnemyType(StrEnum):. I would recommend to not use the mixin version, and instead use theStrEnumclass.* The
create_factoryfunction works as intended for the factory part, but I recommend moving out the skills selection outside the factory creation because they do not affect each other's creation or logic. Meaning, that those can be separated into two separate functions.Other then that, this looks good! Nice that you also added exception handling to the solution! :D
Would these be valid alternatives? I approached things differently than the solution and I'm not sure these fulfill the objectives in the same way.
Ex 2a:
from abc import ABC
from dataclasses import dataclass
from enum import StrEnum
from random import choice, randint
class EnemyType(StrEnum):
KNIGHT = "knight"
ARCHER = "archer"
WIZARD = "wizard"
@dataclass
class Enemy:
enemy_type: EnemyType
health: int
attack_power: int
defense: int
@dataclass
class SpawnPoint(ABC):
stat_range_low: int = 0
stat_range_high: int = 100
@property
def spawnable_enemies(self) -> list[EnemyType]:
return []
def _random_stat(self) -> int:
return randint(self.stat_range_low, self.stat_range_high)
def spawn_enemy(self) -> Enemy:
return Enemy(
enemy_type=choice(self.spawnable_enemies),
health=self._random_stat(),
attack_power=self._random_stat(),
defense=self._random_stat(),
)
@dataclass
class EasySpawnPoint(SpawnPoint):
stat_range_low: int = 0
stat_range_high: int = 35
@property
def spawnable_enemies(self) -> list[EnemyType]:
return [EnemyType.KNIGHT, EnemyType.ARCHER]
@dataclass
class MediumSpawnPoint(SpawnPoint):
stat_range_low: int = 36
stat_range_high: int = 70
@property
def spawnable_enemies(self) -> list[EnemyType]:
return [EnemyType.KNIGHT, EnemyType.ARCHER, EnemyType.WIZARD]
@dataclass
class HardSpawnPoint(SpawnPoint):
stat_range_low: int = 71
stat_range_high: int = 100
@property
def spawnable_enemies(self) -> list[EnemyType]:
return [EnemyType.WIZARD]
def main() -> None:
print("Creating easy enemies.")
for _ in range(4):
print(EasySpawnPoint().spawn_enemy())
print("Creating medium enemies.")
for _ in range(3):
print(MediumSpawnPoint().spawn_enemy())
print("Creating hard enemies.")
for _ in range(2):
print(HardSpawnPoint().spawn_enemy())
if __name__ == "__main__":
main()
Ex 2b:
from abc import ABC
from dataclasses import dataclass
from enum import StrEnum
from random import choice, randint
from typing import Callable
class EnemyType(StrEnum):
KNIGHT = "knight"
ARCHER = "archer"
WIZARD = "wizard"
@dataclass
class Enemy:
enemy_type: EnemyType
health: int
attack_power: int
defense: int
SpawnFn = Callable[[], Enemy]
@dataclass
class SpawnPoint(ABC):
stat_range_low: int = 0
stat_range_high: int = 100
@property
def spawnable_enemies(self) -> list[EnemyType]:
return []
def _random_stat(self) -> int:
return randint(self.stat_range_low, self.stat_range_high)
def spawn_enemy(self) -> Enemy:
return Enemy(
enemy_type=choice(self.spawnable_enemies),
health=self._random_stat(),
attack_power=self._random_stat(),
defense=self._random_stat(),
)
@dataclass
class EasySpawnPoint(SpawnPoint):
stat_range_low: int = 0
stat_range_high: int = 35
@property
def spawnable_enemies(self) -> list[EnemyType]:
return [EnemyType.KNIGHT, EnemyType.ARCHER]
@dataclass
class MediumSpawnPoint(SpawnPoint):
stat_range_low: int = 36
stat_range_high: int = 70
@property
def spawnable_enemies(self) -> list[EnemyType]:
return [EnemyType.KNIGHT, EnemyType.ARCHER, EnemyType.WIZARD]
@dataclass
class HardSpawnPoint(SpawnPoint):
stat_range_low: int = 71
stat_range_high: int = 100
@property
def spawnable_enemies(self) -> list[EnemyType]:
return [EnemyType.WIZARD]
def main() -> None:
spawn_functions: dict[str, SpawnFn] = {
"easy": EasySpawnPoint().spawn_enemy,
"medium": MediumSpawnPoint().spawn_enemy,
"hard": HardSpawnPoint().spawn_enemy,
}
print("Creating easy enemies.")
for _ in range(4):
print(spawn_functions["easy"]())
print("Creating medium enemies.")
for _ in range(3):
print(spawn_functions["medium"]())
print("Creating hard enemies.")
for _ in range(2):
print(spawn_functions["hard"]())
if __name__ == "__main__":
main()
This is a good start! But, some remarks can be made. Let's start with 2a:
* For the
EnemyTypeenum, you could instead useStrEnumand the functionauto(). This brings some extra nice functionality of annotations.* The design, that I see, is quite reliant on implementing methods and attributes. However, there is no indication to help the developer if the needed implementations are done or not. This design is heavily reliant on having default values instead of generic behavior. For example,
spawnable_enemiesshould not be implemented. Instead, it should be an@abstract_methodthat is required to be implemented by the subclasses of the ABC.* The method spawn_enemy and _random_stat is heavily reliant on the attributes stored in the class. If they are not overridden by the subclasses, you will have unexpected behavior. For example, if
EasySpawnPointdoes not define astat_range_high, the linter will not say anything. Because that value is defined inSpawnPoint. I would recommend keeping the ABC as minimal as possible and only implementing generic functionality.The design itself is a good starting point! But, look over those bullet points and you will most likely see some improvements in the code. They do fulfill the objective, but the implementation is a bit too reliant on default values. It is better to let it crash instead of having default values for ABCs.
For 2b:
The
mainfunction is more or less completed. But, the foundation of the different types of spawning is not functional. Because it still relies on ABCs and inheritance which are object-oriented patterns. Instead, try changing each dataclass into a function and then use typing to set constraints. If you get stuck, I would recommend you to look at the example solution.This is my solution for ex2.
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from enum import StrEnum
import random
from typing import Protocol
MIN_IDX_RANGE = 0
MAX_IDX_RANGE = 1
Range = tuple[int, int]
class EnemyType(StrEnum):
KNIGHT = "knight"
ARCHER = "archer"
WIZARD = "wizard"
@dataclass
class Enemy:
enemy_type: EnemyType
health: int
attack_power: int
defense: int
def make_enemy(ranges: EnemyRanges) -> Enemy:
enemy_types = ranges.spawnable_enemies()
enemy_type = enemy_types[random.randint(0, len(enemy_types) - 1)]
return Enemy(
enemy_type=enemy_type,
health=random.randint(
ranges.health_range()[MIN_IDX_RANGE],
ranges.health_range()[MAX_IDX_RANGE],
),
attack_power=random.randint(
ranges.attack_range()[MIN_IDX_RANGE],
ranges.attack_range()[MAX_IDX_RANGE],
),
defense=random.randint(
ranges.defense_range()[MIN_IDX_RANGE],
ranges.defense_range()[MAX_IDX_RANGE],
),
)
class EnemyRanges(ABC):
@abstractmethod
def health_range(self) -> Range: ...
@abstractmethod
def attack_range(self) -> Range: ...
@abstractmethod
def defense_range(self) -> Range: ...
@abstractmethod
def spawnable_enemies(self) -> tuple[EnemyType, ...]: ...
class EasyRanges(EnemyRanges):
def health_range(self) -> tuple[int, int]:
return (1, 5)
def attack_range(self) -> tuple[int, int]:
return (1, 5)
def defense_range(self) -> tuple[int, int]:
return (1, 5)
def spawnable_enemies(self) -> tuple[EnemyType, ...]:
return (EnemyType.KNIGHT, EnemyType.ARCHER)
class MediumRanges(EnemyRanges):
def health_range(self) -> tuple[int, int]:
return (5, 10)
def attack_range(self) -> tuple[int, int]:
return (5, 10)
def defense_range(self) -> tuple[int, int]:
return (5, 10)
def spawnable_enemies(self) -> tuple[EnemyType, ...]:
return (EnemyType.KNIGHT, EnemyType.ARCHER, EnemyType.WIZARD)
class HardRanges(EnemyRanges):
def health_range(self) -> tuple[int, int]:
return (10, 30)
def attack_range(self) -> tuple[int, int]:
return (10, 30)
def defense_range(self) -> tuple[int, int]:
return (10, 30)
def spawnable_enemies(self) -> tuple[EnemyType, ...]:
return (EnemyType.WIZARD,)
FACTORIES = {
"easy": EasyRanges(),
"medium": MediumRanges(),
"hard": HardRanges(),
}
def enemy_ranges_factory(spawn_type: str) -> EnemyRanges:
return FACTORIES[spawn_type]
def spawn_enemy(spawn_type: str) -> Enemy:
print(f"Spawning enemy with {spawn_type}")
enemy_ranges = enemy_ranges_factory(spawn_type=spawn_type)
return make_enemy(ranges=enemy_ranges)
def main() -> None:
for en_t in FACTORIES:
enemy = spawn_enemy(en_t)
print(enemy)
if __name__ == "__main__":
main()
It is a good start. But, there are some changes that needs to be made in order to make the exercise complete.
First, the different ranges classes (
EasyRanges,MediumRanges, andHardRanges) can be replaced by dataclasses for each, with an protocol that sets the requirement that attack, defense, and health is needed.Furthermore, there are some coupling happening between the functions. For example
spawn_enemyandmake_enemyrelies either on global constants or other function definitions. Try breaking these up, and patch everything together in themainfunction.Start with these paragraph and look at the solution to get some inspiration of how this can be improved! :)
Is this a valid alternative to solution of ex1? I tried to follow also the principle of minimum responsibility by letting `Browser` to open only URLs.
from dataclasses import dataclass
from enum import StrEnum
class Camera(StrEnum):
FRONT = "front"
BACK = "back"
def qr_code_to_url() -> str:
qr = QRScanner()
qr.choose_camera(Camera.BACK)
return qr.scan()
@dataclass
class QRScanner:
camera: Camera = Camera.FRONT
def choose_camera(self, camera: Camera) -> None:
print(f"Choosing camera {camera.value}.")
self.camera = camera
def scan(self) -> str:
print(f"Scanning QR code with {self.camera.value} camera.")
return "https://www.arjancodes.com"
class Browser:
def open(self, url: str) -> None:
print(f"Opening {url} in the browser.")
def main() -> None:
print("Navigating to website on device.")
browser = Browser()
browser.open(qr_code_to_url())
Nice that you are applying multiple principles! However, this solution requires a bit more work in terms of separating creation from use. At the moment qr_code_to_url both creates and use the QR instance. I suggest that you move the initializing outside of the function and pass it through arguments.